Pinvon's Blog

所见, 所闻, 所思, 所想

Go实战(三) 基本类型与运算符

1

学习自: Go 语言圣经

2 基本类型

2.1 概述

Go 语言是一种静态类型的编程语言, 所以编译器在编译时就要知道每个值的类型, 这样编译器就知道要为这个值分配多少内存, 并且知道这段分配的内存表示什么.

提前知道值的类型有很多好处, 如, 可以进一步优化代码, 提高执行效率, 等等.

Go 的基本类型包括数值型, 字符串, 布尔型.

数值型包括几种不同大小的整型数, 浮点数, 复数.

2.2 整型

Go 语言提供了有符号和无符号类型的整数运算.

int8, int16, int32, int64 是四种有符号整型; uint8, uint16, uint32, uint64 是四种无符号整型.

另外, 还有 int 和 uint. int 和 uint 的大小会根据当前机器的系统架构自动判定, 一般是 32bit 或 64bit.

rune 类型用于表示一个 Unicode 码点, 它与 int32 等价, 名称可以互换使用.

byte 类型与 int8 类型也是等价的, 可以互换使用.

uintptr 类型用于存放指针, 它与 uint 类型的范围相同, 根据系统架构来确定.

2.2.1 注意

即使根据系统架构确定了 int 类型的大小为 32bit, 我们也不能认为 int 类型与 int32 类型相同, 在需要将 int 当作 int32 使用的地方, 需要进行显式的类型转换.

2.2.2 表示范围

对于有符号整数, 采用 2 的补码形式表示, 即最高位表示符号位, 所以表示范围为 \(-2^{n-1} ~2^{n-1}-1\)

对于无符号整数, 所有的位都用于表示非负数, 因此表示范围是 \(0 ~ 2^{n-1}\)

2.2.3 格式化输出

%d: 输出十进制数

%o: 输出八进制数

%x: 输出十六进制数

%1: 使用第1个操作数

%#: 输出 0, 0x 等前缀

o := 0666
fmt.Printf("%d %[1]o %#[1]o\n", o) // "438 666 0666"
x := int64(0xdeadbeef)
fmt.Printf("%d %[1]x %#[1]x %#[1]X\n", x)
// Output:
// 3735928559 deadbeef 0xdeadbeef 0XDEADBEEF

%q: 输出带单引号的字符

ascii := 'a'
unicode := '国'
newline := '\n'
fmt.Printf("%d %[1]c %[1]q\n", ascii)   // "97 a 'a'"
fmt.Printf("%d %[1]c %[1]q\n", unicode) // "22269 国 '国'"
fmt.Printf("%d %[1]q\n", newline)       // "10 '\n'"

2.3 浮点数

Go 语言的浮点数有 float32 和 float64, 它们符合 IEEE754 标准. 浮点数的极限值可以在 math 包中找到. 如: math.MaxFload32 是 float32 能表示的最大数, 大约为 3.4e38; math.MaxFloat64 是 float64 能表示的最大数, 大约是 1.8e308.

一般我们优先使用 float64, 根据 IEEE754 标准, float32 所能表示的正整数并不是很大, 因为 32bit 的有效位是 23bit, 剩下的要用于指数和符号位, 当整数大于 23bit 能表达的范围时, float32 将出现误差. 如:

var f float32 = 16777216 // 1 << 24
fmt.Println(f == f+1)    // "true"!

2.3.1 格式化输出

格式化输出浮点型时, 我们一般使用 %f, 如 %8.3f 表示输出 8 个字符宽度, 小数保留 3 位.

2.3.2 math 包

math 包提供了大量常用的数学函数和 IEEE754 浮点数标准中定义的特殊值的创建和测试: 正无穷大, 负无穷大, 非数 NaN(用于表示无效的除法操作, 如 0/0). 如:

var z float64
fmt.Println(z, -z, 1/z, -1/z, z/z) // "0 -0 +Inf -Inf NaN"

math.IsNaN() 可以测试一个数是否为非数 NaN, math.Nan() 返回非数对应的值. 但是要注意, NaN 和任何数都是不相等的, 包括它自己:

nan := math.NaN()
fmt.Println(nan == nan, nan < nan, nan > nan) // "false false false"

2.4 复数

Go 语言提供了两种精度的复数类型: complex64 和 complex 128, 分别对应 float32 和 float64 两种浮点数精度.

内置的 complex() 用于构建复数, 内置的 real() 和 image() 分别返回复数的实部和虚部. 如:

var x complex128 = complex(1, 2) // 1+2i
var y complex128 = complex(3, 4) // 3+4i
fmt.Println(x*y)                 // "(-5+10i)"
fmt.Println(real(x*y))           // "-5"
fmt.Println(imag(x*y))           // "10"

如果数字后面直接紧跟一个 i, 则表示它是一个复数的虚部, 而实部是0:

fmt.Println(1i * 1i)  // "(-1+0i)", i^2 = -1

我们也可以用简短变量声明的方式, 来自然的书写复数:

x := 1 + 2i
y := 3 + 4i

2.4.1 math/cmplx 包

math/cmplx 包提供了许多复数处理的函数, 如求复数的平方根函数和求幂函数.

2.5 布尔型

布尔型的值只有 true 和 false 两种, 它们并不会隐式转化成数字 0 和 1.

2.5.1 短路逻辑

布尔值可以和 && 和 || 这两个操作符结合. 如果运算符左边的值已经可以确定整个布尔表达式的值, 那么运算符右边的值将不再被求值, 如:

a := 5
b := 4
if a<0 && b<0 { ... }

由于 && 操作符需要两个值都为真, 结果才返回真. 所以当其判断 a<0 时, 已经知道 && 左边的值为假了, 所以结果肯定为假, 右边的值不需要再判断. || 操作符的短路逻辑类似.

合理安排语句, 使用短路逻辑可以提高程序效率.

2.6 字符串

字符串使用 UTF-8 字符集编码, 用双引号 "" 或反引号 `` 引起来, 是一个不可改变的字节序列. 文本字符串通常被解释为采用 UTF-8 编码的 Unicode 码点(rune)序列.

内置的 len() 返回的是字符串中的 字节 数目, 而不是 rune 字符数目. 索引操作 s[i] 返回的是第 i 个字节的字节值, 而不是字符值. 如:

s := "你好"
fmt.Println(len(s))  // 6
fmt.Println(s[2])  // 160

可以看出, 其长度是 6, 而不是字符数目 2. 第 i 个字节并不一定是字符串的第 i 个字符, 因为对于非 ASCII 字符的 UTF-8 编码会要两个或多个字节.

如果试图访问超出字符串索引范围的字节, 将导致 panic 异常, 如访问 s2.

2.6.1 `` 反引号

如果想声明多行的字符串, 可以使用反引号 ``. `` 中间的字符串为 Raw 字符串, 不会进行转义, 包括换行.

m := `hello
		  world`

//输出 

hello
	world

2.6.2 子字符串操作 s[i:j]

s[i:j] 可以得到 [i,j) 之间的字节, 并生成一个新字符串. 如:

s := "你好"
fmt.Println(s[0:1])  // �

注意, 中文在 UTF-8 编码中, 一个字符占用 3 个字节, 所以如果只截第 1 个字节, 得到的字符将是乱码.

如果忽略 i, 将使用 0 作为开始位置; 如果忽略 j, 将使用 len(s) 作为结束位置.

2.6.3 字符串不可修改

注意, 字符串是不可修改的, 所以试图修改字符串内部数据的操作, 将会报错:

s := "left foot"                                                                                                                           
s[0] = 'L' // compile error: cannot assign to s[0]

如果确实想要修改, 可以将字符串 s 转换为 []byte 类型. 如:

c := []byte(s)
c[0] = 'c'
s2 := string(c)  //再转换回 string 类型
fmt.Printf("%s\n", s2)

除了转成 []byte 类型之外, 还可以使用切片的方式(字符串虽然不能修改, 但可以进行切片操作), 再将切片拼接起来.

2.6.4 字符串拼接

  • 操作符将两个字符串拼接成一个新的字符串. 如:
s := "left foot"
t := s
s += ", right foot"
fmt.Println(s) // "left foot, right foot"
fmt.Println(t) // "left foot"

看起来, t 是得到了 s 的一份拷贝, 但实际上, 由于字符串的不可修改性, 所以如果两个字符串共享相同的底层数据是安全的.

于是, 复制任何长度的字符串的代价是低廉的. 如下图所示, 一个字符串 s 和相应的子字符串切片 s[7:] 的操作可以安全地共享相同的内存, 代价低廉. 复制和切片都没有必要分配新的内存.

5.png

2.6.5 Unicode

一开始的字符集只有 ASCII 字符集: 使用 7bit 来表示 128 个字符.

但是世界上的字符不止 128 个, 还有汉字, 日文, 等等. 所以需要使用另一个字符集来表示所有的符号系统, 这就是 Unicode 的由来.

Unicode 为每个符号都分配一个唯一的 Unicode 码点, Unicode 码点对应 Go 语言的 rune 整数类型(与 int32 等价).

每个 Unicode 码点都使用 32bit 来表示, 方式统一. 但这样会浪费很多存储空间, 比如 ASCII 字符集只要 7bit 就能表示了, 现在却需要扩展到 32bit. 并且, 最常用的字符不到 65536 个, 即 16bit 即可存储.

2.6.6 UTF-8

UTF-8 是 Unicode 的一个实现方式. 它是一种变长的编码方式, 可以使用 1~4 个字节表示一个符号. 如果第 1 个字节以 0 开头, 则表示这是 ASCII 字符, 用一个字节表示, 这样可以节省空间. 如果第 1 个字节以 110 开头, 则表示这个字符需要使用 2 个字节表示, 且后面每个字节都要以 10 开头. 如下所示:

0xxxxxxx                             runes 0-127    (ASCII)
110xxxxx 10xxxxxx                    128-2047       (values <128 unused)
1110xxxx 10xxxxxx 10xxxxxx           2048-65535     (values <2048 unused)
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx  65536-0x10ffff (other values unused)

另外, 在 Unicode 里, 一个中文字符占 2 个字节, 而在 UTF-8 里, 一个中文字符占 3 个字节.

Go 语言的源文件和文本字符串都是以 UTF-8 编码方式进行处理的, 所以我们可以把 Unicode 码点也写到字符串面值中.

2.6.6.1 注意

len() 返回的是字符串所占的字节数, 而不是字符串的长度. 一个 UTF-8 编码的字符, 可能占 1~4 个字节:

import "unicode/utf8"
s := "Hello, 世界"
fmt.Println(len(s))                    // "13"
fmt.Println(utf8.RuneCountInString(s)) // "9"

6.png 如图所示, 我们可以通过 range 循环的索引来访问每个字符, 这个索引会自动以字符为步长, 当一个字符占用的字节数超过 1 时, 步长也会超过 1.

可以利用 UTF-8, 将输入的字符转成 Unicode 字符序列:

s := "プログラム"
fmt.Printf("% x\n", s) // "e3 83 97 e3 83 ad e3 82 b0 e3 83 a9 e3 83 a0"
r := []rune(s)
fmt.Printf("%x\n", r)  // "[30d7 30ed 30b0 30e9 30e0]"

如果将 []rune 类型的 Unicode 字符转为 string 类型, 默认会对它们进行 UTF-8 编码:

fmt.Println(string(r)) // "プログラム"

如果将整数转化为 string 类型, Go 会将该数字当成一个 Unicode 码点, 再转成 UTF-8 类型:

fmt.Println(string(65))     // "A", not "65"
fmt.Println(string(0x4eac)) // "京"

2.6.7 字符串和 Byte 切片

Go 标准库中有 4 个包常 用于对字符串进行处理: strings, bytes, strconv, unicode.

  • strings: 用于字符串的查询, 替换, 比较, 截断, 拆分, 合并等.
  • bytes: 与 strings 类似, 但处理的是 []byte 类型的数据. string 的底层是数组, []byte 的底层是 slice. string 只读, 逐步构建 string 类型的数据, 会导致更多的分配和复制, bytes.Buffer 类型将更加有效.
  • strconv: 提供布尔型, 整型, 浮点型这些类型与字符串类型的相互转换.
  • unicode: 用于给字符串分类, 提供 IsDigit(), IsLetter(), IsUpper(), IsLower() 等类似的功能.
2.6.7.1 例子

我们平时输入的文件名, 一般是这样的形式: /path/to/file.type

编写一个函数, 该函数用于将文件的路径(path/to)删除, 将文件的后缀(.type)删除.

首先是不使用任何库的实现:

func basename(s string) string {
    for i := len(s)-1; i >= 0; i-- {
        if s[i] == '/' {
            s = s[i+1:]
            break
        }
    }
    for i := len(s)-1; i >= 0; i-- {
        if s[i] == '.' {
            s = s[:i]
            break
        }
    }
    return s
}

使用 strings.LastIndex():

func basename(s string) string {
    slash := strings.LastIndex(s, "/") // -1 if "/" not found
    s = s[slash+1:]
    if dot := strings.LastIndex(s, "."); dot >= 0 {
        s = s[:dot]
    }
    return s
}

一个字符串是包含只读字节的 数组, 一旦创建则不可改变;而 []byte 的底层是 slice, slice 的元素则可以自由修改. 字符串和 byte[] 之间的转换:

s := "abc"
b := []byte(s)
s2 := string(b)

内部实现上, []byte(s) 是对 s 进行拷贝, 然后分配一个新的字节数组用于存储该拷贝, 以确保对 b 的修改不会映射到 s 上. 有些场景下, 编译器可以对这个转换进行优化, 尽可能不去分配和复制字符串数据. 而从 s 转化到 b, 是对 b 进行拷贝, 然后存储到 string 类型的内存上, 以确保 s2 是只读的.

我们应尽量避免 string 和 []byte 之间的转换, 因为这会导致内存分配. 所以, 这两种类型都提供了一些实用的函数, 如果这些函数不能达到我们的要求, 再去考虑转换的事:

// strings
func Contains(s, substr string) bool
func Count(s, sep string) int
func Fields(s string) []string
func HasPrefix(s, prefix string) bool
func Index(s, sep string) int
func Join(a []string, sep string) string

// []bytes
func Contains(b, subslice []byte) bool
func Count(s, sep []byte) int
func Fields(s []byte) [][]byte
func HasPrefix(s, prefix []byte) bool
func Index(s, sep []byte) int
func Join(s [][]byte, sep []byte) []byte

此外, 在 bytes 包中还有个 Buffer 类型, 用于 []byte 的缓存. Buffer 可以随着数据的写入而动态增长. 如果是向 bytes.Buffer 写入 ASCII 字符, 使用 WriteByte() 会更高效, 而如果写入的字符是任意的, 最好使用 bytes.Buffer.WriteRune().

func intsToString(values []int) string {
    var buf bytes.Buffer
    buf.WriteByte('[')
    for i, v := range values {
        if i > 0 {
            buf.WriteString(", ")
        }
        fmt.Fprintf(&buf, "%d", v)
    }
    buf.WriteByte(']')
    return buf.String()
}

func main() {
    fmt.Println(intsToString([]int{1, 2, 3})) // "[1, 2, 3]"
}

2.6.8 字符串和数字的转换

数字转字符串: strconv.Itoa() 或 fmt.Sprintf() 都可以.

x := 123
y := fmt.Sprintf("%d", x)
fmt.Println(y, strconv.Itoa(x)) // "123 123"

字符串转数字: strconv.Atoi() 或 strconv.ParseInt() 都可以.

x, err := strconv.Atoi("123")             // x is an int
y, err := strconv.ParseInt("123", 10, 64) // base 10, up to 64 bits(int64)

使用不同的进制来格式化数字: strconv.FormatInt() 或 fmt.Sprintf() 都可以, 推荐使用后者, 更加方便.

fmt.Println(strconv.FormatInt(int64(x), 2)) // "1111011"
s := fmt.Sprintf("x=%b", x) // "x=1111011"

2.7 常量

常量表达式的值在编译期计算, 而不是在运行期. 当操作数是常量时, 一些运行时的错误也可以在编译期被发现, 如除 0 等.

可以批量声明多个常量:

const (
    e  = 2.71828182845904523536028747135266249775724709369995957496696763
    pi = 3.14159265358979323846264338327950288419716939937510582097494459
)

常数之间的运算结果仍是常数, 某些函数(如 len())的返回结果也是常数.

2.7.1 iota 常量生成器

iota 常量生成器有点像 C++ 中的枚举. 我们可以使用它以相似的规则生成一组常量, 其中有 iota 的那一行被置为 0, 其他行依次累加:

type Weekday int

const (
    Sunday Weekday = iota  // 0
    Monday  // 1
    Tuesday // 2
    Wednesday
    Thursday
    Friday
    Saturday
)

再看一个更复杂的例子(有点像枚举类型):

const (
    _ = 1 << (10 * iota)
    KiB // 1024
    MiB // 1048576
    GiB // 1073741824
    TiB // 1099511627776             (exceeds 1 << 32)
    PiB // 1125899906842624
    EiB // 1152921504606846976
    ZiB // 1180591620717411303424    (exceeds 1 << 64)
    YiB // 1208925819614629174706176
)

2.7.2 无类型常量

在 Go 语言中, 除了可以通过 const a int 的方式声明一个有类型的常量之外, 还可以声明一些没有明确基础类型的常量, 并且无类型常量往往有更高的精度, 我们可以认为至少有 256bit 的运算精度.

像 0, 1.0, math.Pi 这些都属于无类型常量. 可以直接用在需要的地方:

var x float32 = math.Pi
var y float64 = math.Pi

当把 math.Pi 这种无类型常量赋给另一个值后, 精度就会改变, 如 x 就不能认为是具有 256bit 的运算精度. 将 x 转为其他类型时, 需要显式转换:

var z int32 = int32(x)

2.8 类型转换

在 Go 语言中, 需要显式地将一个值从一种类型转化成另一种类型. 如:

var apples int32 = 1
var oranges int16 = 2
var compote int = apples + oranges
fmt.Println(compote)

编译时将会报错. 我们可以改成:

...
var compote = int(apples) + int(oranges)
...

如果将大尺寸的数据类型转化成小尺寸的数据类型, 如将浮点数转成整数, 有可能会改变数值或丢失精度. 如:

f := 3.141 // a float64
i := int(f)
fmt.Println(f, i) // "3.141 3"
f = 1.99
fmt.Println(int(f)) // "1"

2.9 习惯

如果没有特殊需求, 我们会倾向于使用有符号类型. 举个例子:

medals := []string{"gold", "silver", "bronze"}
for i := len(medals) - 1; i >= 0; i-- {
    fmt.Println(medals[i]) // "bronze", "silver", "gold"
}

如果 len() 返回的是 uint 类型, 则 len(medals)-1 永远不会小于 0, 并且溢出时变成 uint 类型的最大值, 访问 medals[i] 时出错, 因为在试图访问一个 slice 范围以外的元素.

只有在位运算时, 才会使用无符号类型. 如 bit 集合, 分析二进制文件格式, 哈希, 加密等操作.

3 运算符

所有二元运算符按优先级递减的顺序(同一排的优先级相同)排列如下:

*	/	%	<<	>>	&	&^
+	-	|	^	+=
==	!=	<	<=	>	>=
&&
||

3.1 取模运算符

在不同的语言中, % 运算符的行为可能不同. 在 Go 语言中, % 运算符的符号和被取模数(% 前面的数字)的符号一致. 如 -5%3 和 -5%-3 结果都是 -2.

3.2 溢出

如果运算结果需要更多位数才能表示, 就会导致溢出.

溢出时, 超出的高 bit 位部分将被丢弃. 因此, 有可能出现这种情况: 有符号类型, 超出的高位被截掉之后, 如果剩下的比特中, 最左边的是1, 最终结果就可能成了负数.

3.3 位运算符

^ 当作二元运算符时, 表示按位异或; 当作一元运算符时, 表示按位取反.

3.3.1 例子

var x uint8 = 1<<3 | 3<<5
fmt.Printf("%08b", x)

输出: 01101000

解析: x<<n 中, n 必须是无符号数, x 则有/无符号位都可以. 我们可以用笔来演算, 将 x 转成二进制数, 低位对齐, 然后根据 n 来确定左移的位数. 如:

3<<5

// 开始的位置
       11
0000 0000

// 左移 5 位后的位置
 11       
0000 0000

// 得到的数
0110 0000

1<<3 | 3<<5: 将 1<<3 和 3<<5 的结果用或操作符运算.

Printf() 的 %b: 打印二进制格式的数字, %08b 表示至少打印 8 个字符宽度, 不足的前缀部分用 0 填充.

再看 &^ 的例子:

var x uint8 = 1<<2 | 3<<5
var y uint8 = 1<<1 | 1<<2
fmt.Printf("%08b\n", x)
fmt.Printf("%08b\n", y)
fmt.Printf("%08b\n", x&^y)

输出:

01100100
00000110
01100000

可以看出, x &^ y 的意思是, y 中如果某个比特位为 1, 则 x 中相应的比特位清空成 0.

Footnotes:

1

DEFINITION NOT FOUND.

2

DEFINITION NOT FOUND.

Comments

使用 Disqus 评论
comments powered by Disqus